ANTD table 虚拟滚动

ANTD table 虚拟滚动

前言

Antd团队 2016 年就要注意到 antd 长列表项目的性能问题了,当时让靠分页来解决,问题是很多需求是要单屏展示的,团队也很清楚用虚拟滚动可以有效地解决这个问题。

使用虚拟滚动目的是实现万级以上数据的高性能表格。

简单粗暴直接上代码

搜索方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
const inputRef = useRef<Input>(null);

// 搜索
const getColumnSearchProps = (dataIndex: any, title: string) => ({
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}: any) => (
<div style={{ padding: 8 }}>
<Input
ref={inputRef}
placeholder={`搜索${title}`}
value={selectedKeys[0]}
onChange={e =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() =>
handleSearch(selectedKeys, confirm, dataIndex)
}
style={{ width: 188, marginBottom: 8, display: 'block' }}
/>
<Button
type="primary"
onClick={() =>
handleSearch(selectedKeys, confirm, dataIndex)
}
icon={<SearchOutlined />}
size="small"
style={{ width: 90, marginRight: 8 }}
>
搜索
</Button>
<Button
onClick={() => handleReset(clearFilters)}
size="small"
style={{ width: 90 }}
>
重置
</Button>
</div>
),
filterIcon: (filtered: any) => (
<SearchOutlined
style={{ color: filtered ? '#1890ff' : undefined }}
/>
),
onFilter: (value: any, record: any) =>
record[dataIndex]
? record[dataIndex]
.toString()
.toLowerCase()
.includes(value.toLowerCase())
: '',
onFilterDropdownVisibleChange: (visible: any) => {
if (visible) {
setTimeout(() => inputRef?.current?.select());
}
}
});

const handleSearch = (
_selectedKeys: any[],
confirm: () => void,
_dataIndex: any
) => {
confirm();
};
const handleReset = (clearFilters: () => void) => {
clearFilters();
};

排序方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// columns处理函数包含排序sorter
const formatColumns = (fields: DataCompareField[]) => {
return fields.map((field) => {
const dataIndex = field.item_id ? field.item_id : '日期';
return {
title: `${field.item_name}${field.unit && `(${field.unit})`}`,
dataIndex: getKey(field), //特殊列item_id为空
type: field.value_type,
width: 190,
showSorterTooltip: false,
sorter: (a: any, b: any) => {
if (
field.value_type == 'integer' ||
field.value_type == 'float'
) {
return (
compareColumn(a[dataIndex] || 0) -
compareColumn(b[dataIndex] || 0)
);
} else {
return String(a[dataIndex]).localeCompare(b[dataIndex]);
}
},
...getColumnSearchProps(dataIndex, field.item_name)
}
})
}

const compareColumn = (text: string | number) => {
if (text == '-') text = 0;
return Number(text.toString().replace(',', ''));
};

坑点

最最关键的,官方文档未定义一个方法,导致点击排序或者搜索方法会报scrollTop of undefined

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const [connectObject] = useState<any>(() => {
const obj = {}
Object.defineProperty(obj, "scrollLeft", {
get: () => null,
set: (scrollLeft: number) => {
if (gridRef.current) {
gridRef.current.scrollTo({ scrollLeft })
}
},
})
// 就是这个 可看issues
// https://github.com/ant-design/ant-design/issues/24383
Object.defineProperty(obj, "ownerDocument", {
get: () => document.body.ownerDocument
});

return obj
})

完整的VirtualTable组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
// 完整的VirtualTable

import React, { FC, useEffect, useRef, useState } from "react"
import { Table, Tooltip } from "antd"
import ResizeObserver from "rc-resize-observer"
import { VariableSizeGrid as Grid } from "react-window"
import { TableProps } from "antd/lib/table"

import { strLen } from "@Common/utils/util"

import Style from "./index.module.scss"

interface IProps {
data: {
[key: string]: string | number | null
}[]
columns: IColumn[]
c_size: {
y: number
x: number | undefined
}
isSort?: boolean
}
interface TConfig {
LineHeight: number
AntdThPadding: number
AntdThVPadding: number
AntdTdPadding: number
AntdRowHeight: number
AntdFontSize: number
ThtdBorder: number
}

interface IColumn {
title: string
dataIndex: string
width?: number
sort?: (a: any, b: any) => any
}

const VirtualTable: FC<IProps & Partial<TConfig> & TableProps<any>> = (props) => {
const { columns, c_size, data, isSort } = props
let TConfig: TConfig = {
LineHeight: props.LineHeight || 30,
AntdFontSize: props.AntdFontSize || 14,
AntdRowHeight: props.AntdRowHeight || 40,
AntdThPadding: props.AntdThPadding || 10 * 2,
AntdTdPadding: props.AntdTdPadding || 10 * 2,
AntdThVPadding: props.AntdThVPadding || 5 * 2,
ThtdBorder: props.ThtdBorder || 1,
}

const gridRef = useRef<any>()

const [tableWidth, setTableWidth] = useState(0)
const [connectObject] = useState<any>(() => {
const obj = {}
Object.defineProperty(obj, "scrollLeft", {
get: () => null,
set: (scrollLeft: number) => {
if (gridRef.current) {
gridRef.current.scrollTo({ scrollLeft })
}
},
})
Object.defineProperty(obj, "ownerDocument", {
get: () => document.body.ownerDocument
});

return obj
})

// 合并后的列数
const mergedColumns = ((columns: IColumn[]) => {
const widthColumnCount = columns.filter(({ width }) => !width).length
return columns.map((column) => {
if (column.width) {
return column
}
return {
...column,
width: Math.ceil(tableWidth / widthColumnCount),
}
})
})(columns)

const strLen = (str: string) => {
let len = 0
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
//单字节加1
if ((c >= 0x0001 && c <= 0x007e) || (0xff60 <= c && c <= 0xff9f)) {
len++
} else {
len += 2
}
}
return len
}

// 标题最长长度
const getMaxHeightTh = (columns: IColumn[], scrollbarSize: number) => {
let _max = {
index: 0,
len: 0,
width: 0,
ratio: 0,
}
columns.map((column, i) => {
const titleLen = strLen(column.title)
if (column.width) {
const columnLen = i !== columns.length - 1 ? column.width : column.width - scrollbarSize
const ratio = titleLen / columnLen
if (ratio > _max.ratio) {
_max.ratio = ratio
_max.index = i
_max.len = titleLen
_max.width = columnLen
}
}
})
return _max
}

// 滚动条高度
const getScrollY = (containerHeight: number, scrollbarSize: number) => {
const maxHeightTh = getMaxHeightTh(mergedColumns, scrollbarSize)
const containChars = Math.floor((maxHeightTh.width - TConfig.AntdThPadding - TConfig.ThtdBorder) / TConfig.AntdFontSize) * 2
const titleHeight = TConfig.AntdThVPadding + TConfig.LineHeight * Math.ceil(maxHeightTh.len / containChars) //表头基础高度x px,超过y个纯字符换行,每换行增加z px
let columnsBoxHeight = containerHeight - titleHeight //最小高度最大值;即容器高度
if(isSort) columnsBoxHeight -= 30
return columnsBoxHeight
}

const resetVirtualGrid = () => {
if(gridRef.current){
gridRef.current.resetAfterIndices({
columnIndex: 0,
shouldForceUpdate: true, //TODO
})
}

}

// 调整大小时会频繁渲染,可能有优化空间
const renderVirtualList = (rawData: any[], { scrollbarSize, ref, onScroll }: any) => {
ref.current = connectObject
const scrollY = getScrollY(c_size.y, scrollbarSize)
const columnsBoxHeight = scrollY
const columnsRealHeight = rawData.length * TConfig.AntdRowHeight //data所占高度;即列实高
const minHeight = columnsRealHeight > columnsBoxHeight ? columnsBoxHeight : columnsRealHeight
const maxHeight = columnsRealHeight > columnsBoxHeight ? columnsBoxHeight : columnsRealHeight + scrollbarSize

return !!rawData.length ? (
<Grid
ref={gridRef}
className="gridContent"
columnCount={mergedColumns.length}
columnWidth={(index) => {
const { width } = mergedColumns[index]
return columnsRealHeight > columnsBoxHeight &&
index === mergedColumns.length - 1
? width! - scrollbarSize
: width!
}}
width={tableWidth}
rowCount={rawData.length}
rowHeight={() => TConfig.AntdRowHeight}
height={columnsRealHeight > columnsBoxHeight ? columnsBoxHeight : columnsRealHeight + scrollbarSize}
onScroll={({ scrollLeft }) => {
onScroll({ scrollLeft })
}}
style={{
height: "auto",
minHeight: minHeight,
maxHeight: maxHeight,
}}
>
{({ columnIndex, rowIndex, style }) => {
const value = (rawData[rowIndex] as any)[mergedColumns[columnIndex].dataIndex]
const columnWidth = columns[columnIndex].width
const containChars = Math.floor((columnWidth! - TConfig.AntdTdPadding - TConfig.ThtdBorder) / TConfig.AntdFontSize) * 2
return (
<div className={Style.virtualTableCell} style={style}>
{columnWidth && strLen(String(value)) > containChars ? <Tooltip title={value}>{value}</Tooltip> : value}
</div>
)
}}
</Grid>
) : undefined
}

useEffect(() => resetVirtualGrid, [tableWidth, columns])

const shouldHeaderScroll = data.length == 0

return (
<ResizeObserver
onResize={({ width, height }) => {
setTableWidth(width)
}}
>
<Table
{...props}
scroll={{ y: getScrollY(c_size.y, 0), x: undefined }}
className={[Style.virtualTable, shouldHeaderScroll ? Style.antdHdScroll : ""].join(" ")}
columns={mergedColumns}
dataSource={data}
style={{ width: mergedColumns.length != 0 && mergedColumns[0].width ? mergedColumns.length * mergedColumns[0].width + 1 : "auto" }}
bordered
pagination={false}
components={{
body: renderVirtualList,
}}
/>
</ResizeObserver>
)
}

export default VirtualTable
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
.virtualTable {
position: absolute;
top: 0;
left: 0;
.virtualTableCell {
box-sizing: border-box;

overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

@include border_color("color_divide", 1px, right);
@include border_color("color_divide", 1px, bottom);
@include text_color("color_font");
padding: 5px 10px;
line-height: 30px;
}
}
.antdHdScroll {
:global {
.ant-table-header {
overflow: auto !important;
}
}
}

使用VirtualTable组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import ResizeObserver from "rc-resize-observer"

export const Tables: FC<IProps> = () => {

const [panelHeight, setPanelHeight] = useState(0)
const formatData = (
fields: Field[],
data: (ReactText | null)[][]
): {
[key: string]: ReactText
}[] => {
return data
.map((rowValues, i) => {
let obj: {
[key: string]: any
} = {}
fields.map((field, j) => {
let key = getKey(field)
obj[key] == undefined &&
Object.defineProperty(obj, getKey(field), {
value: formatValue(rowValues[j], {
type: field.value_type,
}),
enumerable: true,
})
})
return obj
})
.filter((rowObj) => Object.keys(rowObj).length !== 0)
}

return(
<ResizeObserver
onResize={({ height }) => {
let headerHeight = headerRef.current ? headerRef.current.clientHeight : 0
setPanelHeight(height - headerHeight - (isFullScreen ? FULLSCREEN_PADDING * 2 : 0))
}}
>
<div className={Style.panelBody}>
{data && data.fields && data.fields.length ? (
<Spin spinning={spinning}>
<VirtualTable columns={formatColumns(data.fields)} data={formatData(data.fields, data.data)} c_size={{ y: panelHeight, x: undefined }} isSort={true}/>
</Spin>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
</ResizeObserver>
)
}

补充

表格的onchange事件。清空筛选项

1
2
3
4
5
6
7
8
9
10
11
12
const [filteredInfo, setFilteredInfo] = useState<any>(null);

const TableOnChange = (pagination: TablePaginationConfig, filters: Record<string, Key[] | null>, sorter: SorterResult<any> | SorterResult<any>[], extra: TableCurrentDataSource<any>) =>{
if(!filters) return;
setFilteredInfo(filters);
}

formatColumns = () => {
return {
filteredValue: (filteredInfo && filteredInfo[dataIndex]) || null,
}
}

链接

antd虚拟滚动

知乎AntD话题

Ant Design 4.0 的一些杂事儿 - Table 篇

github 开源ant-virtual-table,没看源码,应该和上述实现差不多

感谢你的打赏哦!